Skip to content

Conversation

@nikwithak
Copy link
Contributor

@nikwithak nikwithak commented Nov 15, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-25821

📔 Objective

Migrates the logic for orchestrating API calls for several operations related to the Admin API functionality. Adds the following operations to CiphersClient, which call the appropriate API endpoints:

  • delete
  • delete_many
  • delete_as_admin
  • delete_many_as_admin
  • soft_delete
  • soft_delete_many
  • soft_delete_as_admin
  • soft_delete_many_as_admin
  • restore
  • restore_many
  • restore_as_admin
  • restore_many_as_admin
  • edit_as_admin
  • list_org_ciphers
  • create_as_admin

PR Notes: Sorry for the heft - The line count is high on this PR, but a majority of it is unit tests. This should have been split into multiple tickets, in hindsight.

Note also that this hasn't been tested directly with the client yet, and so additional changes may be needed if bugs are discovered when integrating into the clients (future tickets) - these operations are not currently used anywhere.

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation
    team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed
    issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

@github-actions
Copy link
Contributor

github-actions bot commented Nov 15, 2025

Logo
Checkmarx One – Scan Summary & Detailsb6d3aad6-d2dc-41eb-926f-f2a084e632a0

Great job! No new security vulnerabilities introduced in this pull request

@github-actions
Copy link
Contributor

github-actions bot commented Nov 15, 2025

🔍 SDK Breaking Change Detection Results

SDK Version: vault/pm-25821/cipher-admin-ops (ca6ae3f)
Completed: 2025-12-08 18:57:52 UTC
Total Time: 217s

Client Status Details
typescript ✅ No breaking changes detected TypeScript compilation passed with new SDK version - View Details

Breaking change detection completed. View SDK workflow

@codecov
Copy link

codecov bot commented Nov 15, 2025

Codecov Report

❌ Patch coverage is 76.31579% with 360 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.64%. Comparing base (0d52f61) to head (ca6ae3f).

Files with missing lines Patch % Lines
crates/bitwarden-vault/src/cipher/cipher.rs 50.79% 62 Missing ⚠️
...arden-vault/src/cipher/cipher_client/admin/edit.rs 71.85% 56 Missing ⚠️
...den-vault/src/cipher/cipher_client/admin/delete.rs 71.26% 50 Missing ⚠️
...bitwarden-vault/src/cipher/cipher_client/delete.rs 83.39% 43 Missing ⚠️
...den-vault/src/cipher/cipher_client/admin/create.rs 70.79% 33 Missing ⚠️
...s/bitwarden-vault/src/cipher/cipher_client/edit.rs 11.42% 31 Missing ⚠️
...en-vault/src/cipher/cipher_client/admin/restore.rs 85.92% 28 Missing ⚠️
...itwarden-vault/src/cipher/cipher_client/restore.rs 89.07% 26 Missing ⚠️
...warden-vault/src/cipher/cipher_client/admin/get.rs 0.00% 22 Missing ⚠️
...bitwarden-vault/src/cipher/cipher_client/create.rs 94.19% 9 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #560      +/-   ##
==========================================
+ Coverage   78.59%   78.64%   +0.05%     
==========================================
  Files         281      288       +7     
  Lines       29131    30635    +1504     
==========================================
+ Hits        22895    24093    +1198     
- Misses       6236     6542     +306     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nikwithak nikwithak requested a review from Hinton November 26, 2025 22:01
@nikwithak nikwithak force-pushed the vault/pm-25821/cipher-admin-ops branch from aa8a96d to 7dc66a5 Compare November 26, 2025 22:15
@nikwithak nikwithak marked this pull request as ready for review November 26, 2025 22:15
@nikwithak nikwithak requested review from a team as code owners November 26, 2025 22:15
@claude
Copy link

claude bot commented Nov 26, 2025

Claude finished @nikwithak's task —— View job


Code Review Complete

Todo List

  • Gather PR context and understand changes
  • Run comprehensive code review using bitwarden-code-reviewer agent
  • Post review findings

Overall Assessment: REQUEST CHANGES

I've completed a comprehensive code review of PR #560. The code follows good architectural patterns and has strong test coverage, but there are 2 critical issues that must be fixed before merge.

Critical Issues Found

❌ Finding 1: Dead Code - Unused KeyStore instantiation in restore_many

Location: crates/bitwarden-vault/src/cipher/cipher_client/restore.rs:60-65

The restore_many function creates a new KeyStore instance with a randomly generated key, but this store is never used. The function then correctly uses the key_store parameter for decryption. This is dead code that creates unnecessary cryptographic key material in memory.

let store: KeyStore<KeyIds> = KeyStore::default();
#[allow(deprecated)]
let _ = store.context_mut().set_symmetric_key(
    SymmetricKeyId::User,
    SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
);

Fix: Remove lines 60-65 entirely. The function correctly uses the key_store parameter for decryption.


⚠️ Finding 2: organization_id hardcoded to None in restore_many

Location: crates/bitwarden-vault/src/cipher/cipher_client/restore.rs:70

The organization_id field is hardcoded to None in the API request model, even though restore operations can apply to organization ciphers. This differs from the admin version (restore_many_as_admin) which correctly accepts and passes an organization_id parameter.

api.put_restore_many(Some(CipherBulkRestoreRequestModel {
    ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(),
    organization_id: None,  // ← Hardcoded to None
}))

Fix: Add an organization_id: Option<OrganizationId> parameter to the restore_many function (matching the delete_many pattern) and pass it to the request model.

Impact: This prevents users from restoring organization ciphers using the non-admin endpoint.


Important Design Issues

♻️ Finding 3: Admin operations don't update local repository

Location: All admin operation files

Admin operations explicitly do not update the local repository (as documented: "Affects server data only, does not modify local state"), while non-admin operations do. This creates an inconsistency where admin users must manually refresh their local state after admin operations.

Recommendation: This appears intentional, but document this behavioral difference prominently in the public API docs. Since the PR description notes "these operations are not currently used anywhere," this should be validated during client integration.


🎨 Finding 4: Missing test coverage for error paths

Location: Multiple test files

Codecov reports 90.27% patch coverage with 135 lines missing. The tests have good happy-path coverage but limited error-path testing.

Recommendation: Add tests for:

  • API call failures (network errors, 4xx/5xx responses)
  • Decryption failures with malformed data
  • Repository errors
  • Edge cases (empty lists, missing IDs)

❓ Finding 5: Inconsistent API between admin and non-admin edit operations

Location: edit.rs vs admin/edit.rs

The admin edit function requires passing original_cipher_view as a parameter, while the non-admin version fetches it from the repository internally. This creates an inconsistent API.

Question: Why does the admin endpoint require the caller to provide this? Consider documenting the reason or making the API consistent.


Positive Observations

The PR demonstrates several excellent patterns:

  • ✅ Clean separation between admin and non-admin operations via separate client types
  • ✅ Consistent error handling using the bitwarden_error macro
  • ✅ Good test coverage for happy paths with mocked API clients
  • ✅ Proper use of repository pattern for state management
  • ✅ No security vulnerabilities identified
  • ✅ No hardcoded secrets or sensitive data exposure
  • ✅ Proper encryption key handling through KeyStore abstraction
  • ✅ Adheres to CLAUDE.md architectural guidelines

Security Considerations

No security vulnerabilities identified. The code properly handles encryption keys, has no logging of sensitive data, no hardcoded credentials, and follows established cryptographic patterns.


Recommendations Summary

MUST FIX before merge:

  1. Remove dead code in restore_many (lines 60-65 in restore.rs)
  2. Add organization_id parameter to restore_many function

SHOULD address:
3. Add test coverage for error paths to reach >95% coverage
4. Document the admin vs non-admin local state behavior difference
5. Clarify why admin edit requires original_cipher_view parameter

CONSIDER:
6. Whether admin operations should have an option to update local state
7. Adding integration tests once client integration begins


Files reviewed: All 13 changed files including cipher.rs, cipher_client/mod.rs, and all admin operation files.

@Hinton
Copy link
Member

Hinton commented Nov 27, 2025

Be mindful that the autogenerated API bindings do not have any graceful handling of malformed data. I.e. an enum outside the valid range errors out. https://bitwarden.atlassian.net/browse/PM-6169 tracks improvements to this, and if this improvement is required reach out to platform to ensure it's prioritized.

CC @trmartin4

Copy link
Member

@Hinton Hinton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider moving all the admin commands into a separate client. While they share some common logic I think they fundamentally behave differently and it might be confusing for consumers to call an admin method and not have the state react?

CiphersClient & CiphersAdminClient

/// Generate a new key for the cipher, re-encrypting internal data, if necessary, and stores the
/// encrypted key to the cipher data.
fn generate_cipher_key(
pub(crate) fn generate_cipher_key(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is this change needed? It doesn't look like it's used outside this module.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally I had re-used this for a separate admin file, but I ended up adding it to the same file. I thought I already removed this, will fix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left this in (changed to pub(super) so it can be re-used by the CipherAdminClient since it shares the request types.

repository: &R,
encrypted_for: UserId,
request: CipherCreateRequestInternal,
collection_ids: Vec<CollectionId>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: CipherCreateRequest has organization_id, is there a reason organization is part of the cipher create request while collections are part of the method? Collections are tightly coupled to an organization so this split does not make much sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This object ends up mapping directly to the CreateCipherRequestModel, which is re-used across the different endpoints to create a cipher (POST /cipher, POST /cipher/create, POST /cipher/admin), even though POST /cipher does not take collection_ids. I will play around with ways to re-structure these in a way that fits what you're describing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just leave it for now, but would be nice to clean it up in the future.

Comment on lines 235 to 266
if as_admin && cipher_request.organization_id.is_some() {
cipher = api_client
.ciphers_api()
.post_admin(Some(CipherCreateRequestModel {
collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()),
cipher: Box::new(cipher_request),
}))
.await?
.try_into()?;
} else if !collection_ids.is_empty() {
cipher = api_client
.ciphers_api()
.post_create(Some(CipherCreateRequestModel {
collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()),
cipher: Box::new(cipher_request),
}))
.await
.map_err(ApiError::from)?
.try_into()?;
repository
.set(require!(cipher.id).to_string(), cipher.clone())
.await?;
} else {
cipher = api_client
.ciphers_api()
.post(Some(cipher_request))
.await
.map_err(ApiError::from)?
.try_into()?;
repository
.set(require!(cipher.id).to_string(), cipher.clone())
.await?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What's going on here? It doesn't really make much sense to have three different endpoints for creating ciphers.

  • Is ciphers/create not identical to doing post on ciphers?
  • From a REST perspective having a dedicated admin endpoint is also quite weird.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three endpoints is because of the way the backend is structured today.

  • POST /ciphers is only used for creating an individual cipher. Even though it accepts an organization_id in the request, it does not have anywhere to add collection_ids.
  • POST /ciphers/create is how we create an org cipher, as it accepts the collection_ids.
  • The admin endpoint has its own uses that do a separate set of permission checks than the standard endpoint.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated this so that it only calls two endpoints (POST /ciphers or POST /ciphers/create), adn moved the third (/ciphers/admin) to a separate file / client.

Comment on lines 315 to 330
/// Creates a new [Cipher] and saves it to the server.
pub async fn create(
&self,
request: CipherCreateRequest,
) -> Result<CipherView, CreateCipherError> {
self.create_cipher(request, vec![], false).await
}

/// Creates a new [Cipher] for an organization, and saves it to the server.
pub async fn create_org_cipher(
&self,
request: CipherCreateRequest,
collection_ids: Vec<CollectionId>,
) -> Result<CipherView, CreateCipherError> {
self.create_cipher(request, collection_ids, false).await
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contract here is weird. CipherCreteRequest allows you to specify an organization id. That implies you can make orgnaizational items.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree here - the CipherCreateRequest was built to match the valid fields on the API request model, but when we create an org cipher, the client calls the separate (POST /ciphers/create) endpoint, which takes the collection_Ids. The standard POST /ciphers endpoint does not have collection_ids in the response model.

I'm thinking of modifying the CipherCreateRequest to have both org_id and collection_ids, and writing the logic to call the POST /ciphers/create endpoint iff they are both present on the request; LMK your thoughts?

Comment on lines +354 to +357
use wiremock::{
Mock, ResponseTemplate,
matchers::{method, path},
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: We've stopped using wiremock for api mocking. You can look at crates/bitwarden-vault/src/folder/edit.rs for up to date exampels of using ApiClient::new_mocked.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated - now uses standalone methods, and updated unit tests to test those standalones using ApiClient::new_mocked with passed-in dependencies.


impl CipherEditRequest {
fn generate_cipher_key(
pub(crate) fn generate_cipher_key(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: this doesn't seem used outside of this module.

Comment on lines +49 to +50
#[error(transparent)]
Decrypt(#[from] DecryptError),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What operation requires decrypt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the function input for these requests is plaintext, and the SDK handles encryption before sending to the server, we return the updated object decrypted as well. example

We return a CryptoError in other instances (e.g. the edit_cipher implementation here), but this call returns a DecryptError. I will look into casting it approriately to use the same error.

Tangentially: Do you think it makes sense to use this approach - returning the decrypted CipherView object to the consumer, rather than the encrypted Cipher (all of it locally only)

Comment on lines 379 to 381
// putCipherAdmin(id, request: CipherRequest)
// ciphers_id_admin_put
#[allow(missing_docs)] // TODO: add docs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Add documentation.

Comment on lines +68 to +69
#[error(transparent)]
Api(#[from] ApiError),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The methods putting Api errors in CipherError should really be updated to not use cipher error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only one still using CipherError is the share operations, which calls existing functions that currently returns CipherError already (e.g. https://github.com/bitwarden/sdk-internal/blob/vault/pm-25821/cipher-admin-ops/crates/bitwarden-vault/src/cipher/cipher_client/share_cipher.rs#L180-L184) - I think we can migrate this one to its own error type in the future.

Comment on lines +1116 to +1122
edit: Default::default(),
favorite: Default::default(),
folder_id: Default::default(),
permissions: Default::default(),
view_password: Default::default(),
local_data: Default::default(),
collection_ids: Default::default(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reflection: Putting default values here can be dangerous, because there is nothing to indicate the Cipher model is incomplete.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately several API operations don't return the full cipher, only the CipherMiniResponseModel - for the Admin operations, since we don't update the repository, the intent is to still return a consistent type the user can handle, if needed - the concern is valid though. I think we can:

  1. Merge it with the known existing Cipher data (if it's available),
  2. Continue with this approach, knowing it's primarily for the admin operations which don't update local state - if this route, I can move the logic to be private to the admin operations, rather than a blanket From implementation,
  3. Return the CipherMiniResponseModel as-is, or create a MiniCipherView type for mapping / returning,which doesn't expose these fields at all.

Do you have a preference? Feel free to ping me on Slack when you're free for a deeper discussion - there are a handful of API operations that currently only return a subset of data like this.

@nikwithak nikwithak requested a review from Hinton December 5, 2025 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants